사내 서비스 운영 경험을 토대로 작성한 글입니다.

이제는 더 이상 물러날 곳이 없다

매체사 적립을 담당하는 포스트백 서비스는 동일한 카프카 컨슈머 그룹으로 구성돼 있다. 하나의 토픽에서 이벤트를 컨슈밍하고 이벤트 내용에 맞춰 적절한 매체사로 적립 요청을 보낸다. 연동된 매체사 수는 대략 100여개가 넘고 수십개의 컨슈머 파드가 매체사의 적립을 담당한다. 이때 하나의 파드가 하나의 매체사만 담당하는 것은 아니며 단일 파드가 이벤트를 컨슈밍하는 대로 여러 매체사에 적립 요청을 보내는 셈이다. 정리하면 하나의 컨슈머 그룹에서 모든 매체사의 적립 요청을 담당하고 있다.

하지만 최근 특정 대형 매체사의 요구사항이 추가되면서 토픽 분리의 필요성을 느끼게 됐는데, 대략적인 요구사항은 적립 서버에서 해당 매체사로 요청을 보내기 전에 backpressure 기능을 추가해 특정 tps를 초과히지 않도록 제어해달라는 내용이었다. 쉽게 말해 일정 이상의 부하가 매체사 서버로 몰리지 않도록 제어해달라는 말이었다. 특정 tps 이상 초과 시, 매체사쪽 서버에서 에러를 응답하면 우리 적립서버는 웅답받은 에러를 재시도 배치에 적재한다. 정해진 주기 마다 재시도 배치 잡이 돌면서 요청을 보내면서 적립 이벤트를 누락없이 전송하는데, 문제는 실패 건수가 과도하게 쌓이면서 재시도 배치 실행 시 연속적으로 tps를 초과해버리는 상황이 발생했다. 실패한 재시도 요청은 또 다른 재시도 배치에 들어가면서 카프카 lag을 키웠고, 해당 매체사로 적립 스파이크 발생 시 실패가 적립되면서 카프카 Lag도 폭증하는 악순환으로 이어졌다. 이로 인해 해당 토픽을 공유하는 다른 매체사의 적립 요청도 지연이 발생하기 시작했다.

레디스 같은 추가 인프라를 증설하여 tps 제한을 걸 수도 있었지만, 인프라 증설 비용과 다른 매체사 영향도 격리까지 고려하면 아예 토픽을 분리하는 방향으로 가닥을 잡게 됐다.

서론이 길었는데 이번 케이스는 과도한 트래픽을 위한 토픽 분리가 아닌 특정 매체사의 요구사항을 안정적으로 처리하기 위한 대응으로 볼 수 있다. 토픽 분리가 과도한 대응이 아니냐고 반문한다면, 해당 매체사가 우리 고객 중 top tier 매체사이기 때문이기에 안정성을 위해서라도 격리가 필요한 상황이라 답할 수 있다.

Partition

Partition 수 선정 기준

격리 대상 매체사의 트래픽은 전체의 24% 정도를 차지하는데, 연동된 매체사가 100여 곳이 넘는 점을 고려하면 하나의 매체사에서 발생하는 트래픽 규모가 상당하다는 걸 체감할 수 있다.

이번 토픽/컨슈머 분리 작업은 대규모 트래픽을 병렬 처리하는 동시에 tps 한도를 넘지 않는 선에서 트레이드 오프를 해야했다. 단순히 tps 한도만 지키려한다면 제한된 파티션 수와 폴링 속도를 늦추는 방식으로 제어할 수 있었겠지만, 대규모 트래픽을 무작정 지연처리할 수는 없기에 적정 규모의 파티션 수 산정이 필요했다.

해당 매체사의 트래픽은 대략 1/4을 차지하지만 단순 계산으로 파드와 파티션 수를 정할 수는 없다. 파티션과 파드는 산정 기준이 다른데 파티션은 최대 카프카 컨슈머 프로세스 수를 기준으로, 파드는 피크 TPS × 처리시간과 backlog 해소 목표 기준으로 정했다. 최대 tps 한도를 넘지 않는 선에서 운영하려면 기존 멀티 프로세스로 운영되던 컨슈머를 싱글 프로세스로 변경하기로 했고, 최대 HPA 수에 맞춰 파티션수도 선정해야 했다.

매체사에서 요구하는 tps 한도를 250으로 가정하면 아래처럼 메트릭을 산정할 수 있다.

text
partitions: 10
processes per pod: 1
HPA: 3~5 pods
local limiter: 50 TPS/process
maxSurge: 0

위 스펙은 파드가 max HPA에 도달하더라도 기준 tps에 맞춰 요청을 처리할 수 있다. 파티션 수는 최대 HPA 수 보다 여유를 둬, 추후 tps 제한이 높아지거나 최대 10개 consumer까지 partition 증설 없이 확장이 가능한 상황을 고려하여 산정했다. 매체사에서 250 TPS를 고정하는 동안은 HPA max를 5로 고정하고, 추후에 tps 한도가 늘어날 때 HPA를 늘려도 괜찮다고 판단했다. 전역 limiter가 없으므로 local limit × 최대 동시 프로세스 수가 유일한 상한선이다.

파티션 키는 기존과 동일하게 사용

partition message key는 기존 토픽과 동일하게 사용자 식별자를 유지했다. 전역 순서는 보장하지 않지만 같은 사용자의 요청은 동일 파티션에서 처리되므로 사용자 단위의 순서는 유지할 수 있다. 한 사용자의 적립 순서는 중요하지만, 여러 사용자의 적립 순서를 지키는 것은 강한 제약사항이 필요하지 않기 때문에 파티션 키는 사용자 식별자를 유지하기로 했다.

Consumer

기존 consumer는 python/django 애플리케이션으로 운영중이며 memory usage가 높은 편이다. 서비스 특성 상 http i/o가 주 병목이지만 그에 못지않게 메시지 serialize/deserialize 와 db i/o 작업도 많이 수행한다. 또한 django orm에서 mysql pool 을 지원해주지 않는 점(진짜 왜 이러는지 모르겠다) 때문에 급격한 scale out 발생시 db connections이 높아지면서 too many connections 에러도 종종 발생했다. 전사적으로 go application을 운영/도입하는 기조이기 때문에 이번 기회에 consumer를 포팅 하고싶은 욕심도 있었지만, top tier 매체사의 컨슈머를 무중단으로 마이그레이션 해야 하는 점과 매체사별로 토픽을 분리하는 작업이 처음인 점을 고려하여 기존 로직을 그대로 사용할 수 있도록 동일한 코드를 사용하기로 했다.

메시지 처리 완료와 offset commit

offset commit은 enable.auto.commit=true 조건으로 설정했다. 위 옵션의 장점은 우선 Kafka 브로커로 offset을 직접 커밋하는 코드를 작성하지 않아도 되므로 적립 요청이라는 도메인 로직에만 집중할 수 있다. 매 메시지마다 commit()을 호출하지 않아도 되니, 동기식으로 인한 처리 지연과 커밋 요청 수도 줄일 수 있다. 메시지가 완전히 처리되지 않았음에도 자동으로 커밋되어 이벤트가 누락되는 문제가 발생할 수 있지만, 기존 컨슈머도 동일한 옵션을 사용하기도 했고 재시도 배치에서 해당 메시지를 다시 처리해주기 때문에 해당 옵션을 사용해도 안정적으로 메시지를 처리할 수 있다.

재시도 배치로 인한 lag 폭증?

앞서 재시도 배치로 인해 tps를 초과하는 문제가 발생했던 점을 언급했다. 재시도 배치 역시 이번에 분리하는 토픽을 통해 이벤트가 발행되기 때문에, 이번 작업이 완료되면 재시도 요청이 폭증하더라도 tps 제한을 넘지 않으면서 요청을 처리할 수 있다. lag은 좀 많이 쌓이겠지만 해당 토픽은 다른 매체사와 공유하지 않기 때문에, 타 매체사 영향을 줄일 수 있다는 점에서 안정적이라 판단했다. lag가 쌓이면서 해당 매체사의 요청 처리는 오래걸리겠지만, 어쩌겠는가 애초에 그쪽에서 tps 제한을 걸었으니...

Consumer group과 배포 단위도 분리

토픽만 나누고 기존 consumer deployment가 두 토픽을 함께 읽는다면 장애와 확장의 경계는 여전히 같다. 전용 consumer group과 deployment를 만들고 HPA, process 수, limiter 설정도 따로 관리하기로 했다. maxSurge: 0도 같은 맥락이다. rolling update 중 추가 pod가 떠서 local limiter의 총합이 순간적으로 커지는 일을 막는다. 배포 중 처리 용량이 잠시 줄어드는 대신 TPS 상한을 우선한 선택이다. 처리 로직까지 새로 만들지는 않았다. 기존 handler, 성공 판정, idempotency, metric, tracing과 Docker image를 그대로 사용하고 topic, consumer group, process 수와 deployment 설정만 분리했다.

운영하면서 무엇을 볼 것인가

Lag만으로 상태를 판단하지 않는다

TPS가 제한된 consumer에서 lag 증가는 항상 장애를 의미하지 않는다. 유입량이 한도를 넘으면 backlog가 생기는 것이 의도한 동작이고 중요한 것은 backlog가 얼마나 오래 남아 있는 것인지 계속 모니터링 하는 것인데, 이건 요청이 꾸준히 처리되길 기다리는 수 밖에 없다(지금은 상황이 그렇다).

HPA 역시 CPU만으로 판단하기 어렵다. HTTP I/O가 주 병목이고 limiter가 처리량을 막고 있으므로 CPU가 낮아도 메시지는 쌓일 수 있다. lag와 oldest message age를 기준으로 확장하되, HPA max는 처리 용량이면서 동시에 매체사에 보낼 수 있는 TPS의 안전장치로 둔다.

서로 다른 토픽 사이에서는 consumer group의 offset을 그대로 옮길 수 없다. 새 topic과 consumer를 먼저 준비한 뒤 producer routing을 변경하고, 기존 topic에 남아 있는 해당 매체사의 메시지를 모두 처리하는 순서로 전환해야 한다. 전환 중에는 새 consumer의 outbound TPS와 에러율을 확인한다. 문제가 생기면 producer routing을 기존 topic으로 되돌리고 기존 topic에 남은 분리 대상 매체사의 메시지가 모두 처리될 때까지 기존 경로를 유지한다.

토픽 하나를 더 만든다는 것

처음에는 전체 트래픽의 1/4을 차지하니 파드와 파티션도 1/4로 나누면 되지 않을까 생각했다. 하지만 파티션은 최대 consumer 수를, 파드는 처리시간과 backlog 해소 목표를, limiter는 매체사와 약속한 TPS를 기준으로 정해야 했다.

결국 이번 작업에서 분리한 것은 topic 뿐 아니라 consumer group, 확장 정책, 재시도 트래픽, 모니터링과 장애 영향 범위를 함께 분리했다. 반대로 검증된 business logic과 runtime은 그대로 두어 영향 범위를 줄였다. 토픽 분리는 처리량을 늘리는 만능 해법이 아니다. 운영 정책이나 장애 허용 범위가 다른 트래픽을 독립적으로 제어해야 할 때 비로소 의미가 있다. 토픽 하나를 더 만드는 일은 메시지가 흐르는 길을 하나 추가하는 것이 아닌, 운영 책임과 실패 경계를 새로 정하는 일이라 생각됐다.